#!/usr/bin/env python
# coding: utf-8
#
# Compute and show the center of mass for multiple solids
#
# Usage:
# 1. Select one or multiple solids.
# 2. Launch the macro.
# 3. You'll have a window listing the solids. You can put the density of your
#    material in different unit systems or choose from predefined materials.
#
# Options:
# * Color the solids according to density.
# * Display where is the center of mass.
# * Export and import masses, materials and densities (even if it's not a .csv
#  file from the macro, as soon as there is a column named accordingly).
# * Save densities in the document (remove them again when setting material
#  to "default")
#
# Credits:
# 2018 - 2022: schupin
# 2022 - 2023: SyProLei project (Saarland University)
#

__Name__ = 'CenterOfMass'
__Comment__ = 'Compute and show the center of mass for multiple solids'
__Author__ = 'chupins, s-quirin'
__Version__ = '0.7.3'
__Date__ = '2023-09-07'
__License__ = 'LGPL-3.0-or-later'
__Web__ = 'https://forum.freecad.org/viewtopic.php?f=24&t=31883'
__Wiki__ = 'https://wiki.freecad.org/Macro_CenterOfMass'
__Icon__ = 'https://wiki.freecad.org/images/a/a6/Macro_CenterOfMass.svg'
__Help__ = 'Select one or more bodies and launch'
__Status__ = 'Alpha'
__Requires__ = 'FreeCAD >= 0.19'
__Communication__ = 'https://forum.freecad.org/viewtopic.php?f=24&t=31883'
__Files__ = ''

# Todo:
# - error with draft array of meshes (relevant?)
# - App:Link lacks the .getGlobalPlacement() method and childShapes() do not equal,
# so .InListRecursive and .OutList is used as a workaround.
# @realthunder proposes https://forum.freecad.org/viewtopic.php?p=569083#p569083
# Ideas:
# - moments of inertia with arrows that allow the user to see the relative magnitudes

import copy
import csv
import os
import math

from PySide import QtCore, QtGui    # FreeCAD's PySide!

import FreeCAD as app
import FreeCADGui as gui
from FreeCAD import Units

# Preferences
MAXIMUM_DENSITY = '25000 kg/m^3'    # maximum physically meaningful value
VALUE_DELIMITER = '\t'              # delimiter-separated values format: Tab

# FreeCAD: Tools -> Edit Parameters, 2nd parameter of .GetX sets the default
MACRO_SETTINGS  = 'User parameter:BaseApp/Preferences/Macros/' + __Name__
DEFAULT_DENSITY = app.ParamGet(MACRO_SETTINGS).GetString('Default density', '2500 kg/m^3')
DOCKED_WINDOW   = app.ParamGet(MACRO_SETTINGS).GetBool('Docked window', True)    # False: floating
SORT_SELECTION  = app.ParamGet(MACRO_SETTINGS).GetBool('Sort selection', False)
COLOR_SPHERES = app.ParamGet(MACRO_SETTINGS).GetBool('Color spheres', False)
COLOR_SATURAT = app.ParamGet(MACRO_SETTINGS).GetUnsigned('Color saturation', 80)
COLORMAP_USER = app.ParamGet(MACRO_SETTINGS).GetString('Matplotlib colormap', 'Spectral_r')
GUI_FONT_SIZE = app.ParamGet('User parameter:BaseApp/Preferences/Editor').GetInt('FontSize', 10)
GUI_ICON_SIZE = app.ParamGet('User parameter:BaseApp/Preferences/General').GetInt('ToolbarIconSize', 24)

MATERIAL_SETTINGS = app.ParamGet('User parameter:BaseApp/Preferences/Mod/Material/Resources')
USE_BUILT_IN_MATERIALS  = MATERIAL_SETTINGS.GetBool('UseBuiltInMaterials', True)
USE_MAT_FROM_CONFIG_DIR = MATERIAL_SETTINGS.GetBool('UseMaterialsFromConfigDir', True)
USE_MAT_FROM_CUSTOM_DIR = MATERIAL_SETTINGS.GetBool('UseMaterialsFromCustomDir', True)
CUSTOM_MAT_DIR = MATERIAL_SETTINGS.GetString('CustomMaterialsDir', '')

# Floating window size preferences: fraction of primary screen's size
WINDOW_WIDTH = int(0.2 * QtGui.QGuiApplication.screens()[0].geometry().width())
WINDOW_HEIGHT = int(0.5 * QtGui.QGuiApplication.screens()[0].geometry().height())

if int(app.Version()[1]) < 21:
    # FreeCAD 0.20 is able to run with Qt 5.9.5 but Qt 5.12 was adopted here
    if QtCore.QLibraryInfo.version() < QtCore.QVersionNumber(5,12):
        app.Console.PrintWarning('Qt 5.12 or higher is needed to run\n')

app.ParamGet(MACRO_SETTINGS).SetString('Default density', DEFAULT_DENSITY)
app.ParamGet(MACRO_SETTINGS).SetBool('Docked window', DOCKED_WINDOW)
app.ParamGet(MACRO_SETTINGS).SetBool('Sort selection', SORT_SELECTION)
app.ParamGet(MACRO_SETTINGS).SetBool('Color spheres', COLOR_SPHERES)
app.ParamGet(MACRO_SETTINGS).SetUnsigned('Color saturation', COLOR_SATURAT)
app.ParamGet(MACRO_SETTINGS).SetString('Matplotlib colormap', COLORMAP_USER)
g_main_window = gui.getMainWindow()
g_font = g_main_window.font()
g_font.setPointSize(GUI_FONT_SIZE)
g_font_metrics = QtGui.QFontMetrics(g_font)
g_str_width = g_font_metrics.horizontalAdvance('_0_000e+00_')
g_icon_size = QtCore.QSize(GUI_ICON_SIZE, GUI_ICON_SIZE)
g_sel_user = []    # the user list of selected objects
g_sel = []    # the valid list of selected objects


class CenterofmassDock(QtGui.QDockWidget):
    """if DOCKED_WINDOW = True"""
    def __init__(self):
        super().__init__()
        self.setAttribute(QtCore.Qt.WA_DeleteOnClose, True)    # free memory
        self.setLocale(QtCore.QLocale.English)
        self.child = CenterofmassWidget(self)
        self.setWidget(self.child)
        g_main_window.addDockWidget(QtCore.Qt.RightDockWidgetArea, self)


class CenterofmassWindow(QtGui.QMainWindow):
    """if DOCKED_WINDOW = False"""
    def __init__(self, parent=g_main_window):
        super().__init__(parent)    # parent: Window stays on top
        self.setLocale(QtCore.QLocale.English)
        self.child = CenterofmassWidget(self)
        self.setCentralWidget(self.child)
        self.set_position()
        self.show()

    def set_position(self):
        """Set a sensible default position for the window.
        With FreeCAD's default layout, this will be over the Combo View.
        """
        geom = g_main_window.geometry()
        xpos = geom.left() + 50
        ypos = geom.center().y() - WINDOW_HEIGHT // 2
        self.setGeometry(xpos, ypos, WINDOW_WIDTH, WINDOW_HEIGHT)


class SolidsWidget():
    """Rows in scroll area"""
    def __init__(self, parent, solid, sol):
        self.parent = parent
        self.sol = sol
        color = solid.ViewObject.ShapeColor
        self.orgColorFC = color
        self.orgColorQT = QtGui.QColor.fromRgbF(*color)
        self.orgTransparency = solid.ViewObject.Transparency
        self.labelC = QtGui.QLabel(' ', parent)
        self.label = QtGui.QLabel(solid.Label, parent)
        self.combo = QtGui.QComboBox(parent)
        self.spinDens = QtGui.QDoubleSpinBox(parent)
        self.spinMass = QtGui.QLineEdit(parent)

        # init properties
        self.labelC.setStyleSheet(
            'QLabel {background-color: %s}' % self.orgColorQT.name())
        self.labelC.setMaximumWidth(GUI_ICON_SIZE)
        self.combo.addItem('custom')
        self.combo.setMinimumContentsLength(14)    # auto size adjustment and truncation
        d_min, d_max = parent.material_base_range
        if d_min == d_max:
            self.combo.addItems(parent.material_base)
        else:
            # icons filled according to density relation
            qs = len(parent.pieIcon) - 1
            for m in parent.material_base:
                q = math.ceil((parent.material_base[m] - d_min) / (d_max - d_min) * qs)
                self.combo.addItem(QtGui.QIcon(parent.pieIcon[q]), m)
            self.combo.setItemIcon(1, QtGui.QIcon())    # no icon for 'default'
        self.combo.insertSeparator(2)    # after custom+default
        mat_name = getattr(g_sel[sol], 'Mat_Name', 'default')
        mat_density = getattr(g_sel[sol], 'Mat_Density', parent.material_base['default'])
        # class 'Base.Quantity' for saves with FreeCAD > 0.21, before: class 'str'
        if hasattr(g_sel[sol], 'Material'):
            if hasattr(g_sel[sol].Material, 'Material'):
                # overwrite if Arch Material set
                mat_name = g_sel[sol].Material.Material['CardName']
                mat_density = g_sel[sol].Material.Material['Density']    # class 'str'
        self.combo.setCurrentText(mat_name)    # not found: stays 0=custom
        self.combo.currentIndexChanged.connect(self.on_comboMaterial_changed)
        self.init_spinDensity(self.spinDens, Units.Quantity(mat_density), parent.unitForD)
        self.spinDens.setToolTip(
            'density of ' + solid.Label + ' (in ' + parent.unitForD_text + ')')
        self.spinDens.valueChanged.connect(self.on_spinDensity_changed)
        self.spinMass.setAlignment(QtCore.Qt.AlignCenter)
        self.spinMass.setMinimumWidth(g_str_width)
        self.spinMass.setMaximumWidth(g_str_width + GUI_FONT_SIZE)
        self.spinMass.editingFinished.connect(self.on_spinMass_edited)

        # layout grid
        if sol == 0:
            searchL = QtGui.QLineEdit(parent, placeholderText='Search...')
            searchL.setClearButtonEnabled(True)
            searchL.textEdited.connect(parent.on_searchL_edited)
            label02 = QtGui.QLabel('Material', parent)
            label03 = QtGui.QLabel('Density', parent)
            label04 = QtGui.QLabel('Mass', parent)
            parent.solidLayout.addWidget(searchL, sol, 1)
            parent.solidLayout.addWidget(label02, sol, 2)
            parent.solidLayout.addWidget(label03, sol, 3)
            parent.solidLayout.addWidget(label04, sol, 4)
        parent.solidLayout.addWidget(self.labelC, sol+1, 0)
        parent.solidLayout.addWidget(self.label, sol+1, 1)
        parent.solidLayout.addWidget(self.combo, sol+1, 2)
        parent.solidLayout.addWidget(self.spinDens, sol+1, 3)
        parent.solidLayout.addWidget(self.spinMass, sol+1, 4)

    def init_spinDensity(self, spin, qty, unitForD):
        spinBoxDigits = 6
        # don't trigger on_spinDensity_changed (e.g. when on_comboUnitDensity_changed)
        spin.blockSignals(True)
        spin.setRange(0., Units.Quantity(MAXIMUM_DENSITY).getValueAs(unitForD))
        decimals = spinBoxDigits - math.floor(math.fabs(math.log10(spin.maximum())))
        spin.setDecimals(decimals)
        spin.setStepType(QtGui.QAbstractSpinBox.StepType.AdaptiveDecimalStepType)
        spin.setValue(qty.getValueAs(unitForD))
        spin.blockSignals(False)

    def on_spinDensity_changed(self):
        self.combo.setCurrentIndex(0)    # set to custom
        self.parent.compute_centerOfMass()

    def on_spinMass_edited(self):
        parent = self.parent
        if self.spinMass.text() == format(parent.masses[self.sol], '.3e'):
            return    # there was no editing
        v_ = QtGui.QDoubleValidator(0., 9.999e99, 3)
        if v_.validate(self.spinMass.text(),0)[0] == QtGui.QValidator.State.Invalid:
            self.spinMass.undo()
            if v_.validate(self.spinMass.text(),0)[0] == QtGui.QValidator.State.Invalid:
                self.spinMass.setText('0.000e00')
            return    # input not in scientific notation
        volumeInUnit = parent.convert_volume(parent.volumes[self.sol])
        dens = float(self.spinMass.text()) / volumeInUnit
        self.spinDens.setValue(dens)    # trigger on_spinDensity_changed

    def on_comboMaterial_changed(self, newIndex):
        materialName = self.combo.currentText()
        qty = self.parent.material_base.get(materialName, 0)
        if qty:
            materialDensity = Units.Quantity(qty).getValueAs(self.parent.unitForD)
            self.spinDens.blockSignals(True)
            # don't trigger on_spinDensity_changed
            self.spinDens.setValue(materialDensity)
            self.spinDens.blockSignals(False)
            self.parent.compute_centerOfMass()


class CenterofmassWidget(QtGui.QWidget):
    """This is the widget which does almost all of the work.
    Widgets don't have close boxes, so closing is dealt with in
    CenterofmassWindow.
    """
    def __init__(self, parent):
        super().__init__(parent)
        self.setObjectName(__Name__)
        parent.setWindowTitle(__Name__ + ' ' + __Version__)
        parent.setFont(g_font)
        self.doc = app.activeDocument()
        self.material_base = {}
        self.solid_count = 0
        objs = self.valid_selection(gui.Selection.getSelection())
        self.init_UI()
        if self.solid_count <= 0:
            self.setDisabled(True)
            self.startup_dialog()
        else:
            self.init_solids(objs)

    def startup_dialog(self):
        """Error dialog allowing a new selection"""
        msg = 'Select a valid object (a solid or a mesh) first.'
        app.Console.PrintError(msg + '\n')
        diag = QtGui.QMessageBox(QtGui.QMessageBox.Critical, 'Error', msg, parent=g_main_window)
        diag.setModal(False)
        diag.setStandardButtons(QtGui.QMessageBox.Ok | QtGui.QMessageBox.Cancel)
        diag.finished.connect(self.on_startup_dialog_finished)
        diag.show()

    def on_startup_dialog_finished(self, result):
        if result == QtGui.QMessageBox.Ok:
            objs = self.valid_selection(gui.Selection.getSelection())
            if self.solid_count <= 0:
                self.startup_dialog()
            else:
                self.init_solids(objs)
                self.setDisabled(False)
        else:
            self.parentWidget().close()

    def init_UI(self):
        """Lay out the interactive elements"""
        # main layout
        layout = QtGui.QVBoxLayout(self)

        # titleLayout
        toPreferences = QtGui.QPushButton(QtGui.QIcon(':/icons/Std_DlgParameter.svg'),'')
        toPreferences.setToolTip('Preferences -> Macros')
        toPreferences.clicked.connect(self.on_pushButton_toPreferences)

        editMaterial = QtGui.QPushButton(QtGui.QIcon(':/icons/Arch_Material_Group.svg'),'')
        editMaterial.setToolTip('Edit Material list')
        editMaterial.clicked.connect(self.on_pushButton_editMaterial)

        label_comboUnit = QtGui.QLabel('Density:')
        label_comboUnit.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)

        comboUnitDensity = QtGui.QComboBox(toolTip='Unit of density')
        comboUnitDensity.addItems(['kg/m³',
                                   'g/dm³', 'g/cm³',
                                   'mg/mm³',
                                   'oz/in³',
                                   'lb/in³', 'lb/ft³', 'lb/yd³'])
        comboUnitDensity.currentTextChanged.connect(self.on_comboUnitDensity_changed)
        # Get units set as preference
        self.store_prefered_units(comboUnitDensity.currentText())

        label_defaultDensity = QtGui.QLabel('Default value:')
        label_defaultDensity.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)

        self.defaultDensitySpin = QtGui.QDoubleSpinBox(self)
        SolidsWidget.init_spinDensity(self,
                                      self.defaultDensitySpin,
                                      Units.Quantity(DEFAULT_DENSITY),
                                      self.unitForD)
        self.defaultDensitySpin.setMinimum(math.pow(10, -self.defaultDensitySpin.decimals()))
        self.defaultDensitySpin.setToolTip(
            'set default density (in ' + self.unitForD_text + ')')
        self.defaultDensitySpin.valueChanged.connect(self.on_spinDefaultDensity_changed)
        self.material_base['default'] = Units.Quantity(DEFAULT_DENSITY)

        iconPath = ':/icons/measure/Part_Measure_Step_Active.svg'
        allToDefaultDensity = QtGui.QPushButton(QtGui.QIcon(iconPath),' to all')
        allToDefaultDensity.setToolTip('Set all solids to default density')
        allToDefaultDensity.clicked.connect(self.on_pushButton_allToDefaultDensity)

        titleLayoutBox = QtGui.QGroupBox('Preferences')
        titleLayout = QtGui.QHBoxLayout()
        titleLayout.addWidget(toPreferences)
        titleLayout.addWidget(editMaterial)
        titleLayout.addWidget(label_comboUnit)
        titleLayout.addWidget(comboUnitDensity)
        titleLayout.addSpacing(GUI_FONT_SIZE)
        titleLayout.addWidget(label_defaultDensity)
        titleLayout.addWidget(self.defaultDensitySpin)
        titleLayout.addWidget(allToDefaultDensity)
        titleLayoutBox.setLayout(titleLayout)
        layout.addWidget(titleLayoutBox)

        # solidGroupBox
        self.load_materials()    # load material cards
        solidGroupBox = QtGui.QWidget()
        self.solidLayout = QtGui.QGridLayout(solidGroupBox)
        self.scroll = QtGui.QScrollArea()
        self.scroll.setWidget(solidGroupBox)
        self.scroll.setWidgetResizable(True)
        self.found_labels = []    # for search

        # custom icon set of pies for SolidsWidget().combo
        self.pieIcon = []
        drawSize = g_font_metrics.height() * 2
        drawArea = QtCore.QRect(0, 0, drawSize-8, drawSize)
        drawArea.adjust(2, 6, -2, -6)    # padding
        for q in range(5):
            pixmap = QtGui.QPixmap(drawSize-8, drawSize)
            pixmap.fill(QtCore.Qt.transparent)
            painter = QtGui.QPainter(pixmap)
            painter.drawEllipse(drawArea)
            if q in range(1,5):
                painter.setBrush(QtGui.QBrush(QtCore.Qt.black))
                painter.drawPie(drawArea, (1-q)*90*16, q*90*16)
                # 1/16th of a degree counterclockwise, zero degrees at the 3 o'clock position
            painter.end()    # needed in Python
            self.pieIcon.append(pixmap)

        # buttonGroupBox
        newSelection = QtGui.QPushButton('New')
        newSelection.setToolTip('Get solids from selection')
        newSelection.setIcon(QtGui.QIcon(':/icons/LinkSelect.svg'))
        newSelection.setIconSize(g_icon_size)
        newSelection.clicked.connect(self.on_pushButton_newSelection)

        update = QtGui.QPushButton('Update')
        update.setToolTip('Update solids from document')
        update.setIcon(QtGui.QIcon(':/icons/view-refresh.svg'))
        update.setIconSize(g_icon_size)
        update.clicked.connect(self.on_pushButton_update)

        save = QtGui.QPushButton('Save')
        save.setToolTip('Copy material property to your document model data')
        save.setIcon(QtGui.QIcon(':/icons/Std_MergeProjects.svg'))
        save.setIconSize(g_icon_size)
        save.clicked.connect(self.on_pushButton_Save)

        export = QtGui.QPushButton('Export')
        export.setToolTip('Export values to a .csv file')
        export.setIcon(QtGui.QIcon(':/icons/Std_SaveCopy.svg'))
        export.setIconSize(g_icon_size)
        export.clicked.connect(self.on_pushButton_Export)

        readDensities = QtGui.QPushButton('Import')
        readDensities.setToolTip('Import masses, materials or densities from a file')
        readDensities.setIcon(QtGui.QIcon(':/icons/Std_Import.svg'))
        readDensities.setIconSize(g_icon_size)
        readDensities.clicked.connect(self.on_pushButton_Import)

        buttonGroupBox = QtGui.QWidget()
        buttonLayout = QtGui.QHBoxLayout(buttonGroupBox)
        buttonLayout.addWidget(newSelection)
        buttonLayout.addWidget(update)
        buttonLayout.addWidget(save)
        buttonLayout.addWidget(export)
        buttonLayout.addWidget(readDensities)
        margin = buttonLayout.contentsMargins()
        buttonLayout.setContentsMargins(0, margin.top()/2, 0, 0)

        self.mainGroupBox = QtGui.QGroupBox('Selected solids: 0')
        mainGroupLayout = QtGui.QVBoxLayout()
        mainGroupLayout.addWidget(self.scroll)
        mainGroupLayout.addWidget(buttonGroupBox)
        self.mainGroupBox.setLayout(mainGroupLayout)
        layout.addWidget(self.mainGroupBox)

        # viewGroupBox
        showCoM = QtGui.QCheckBox(toolTip='Show center of mass')
        if self.doc.getObject('CenterOfMass'):
            showCoM.setChecked(True)
        showCoM.setIcon(QtGui.QIcon(':/icons/Std_ToggleVisibility.svg'))
        showCoM.setIconSize(g_icon_size)
        showCoM.stateChanged.connect(self.on_stateChanged_showCoM)

        self.changeRadius = QtGui.QSlider(QtGui.Qt.Horizontal)
        self.changeRadius.setToolTip('Change radius of spheres')
        self.changeRadius.setEnabled(False)
        self.changeRadius.setMaximum(49)
        self.changeRadius.valueChanged.connect(self.on_slideButton_changeRadius)

        self.checkColorify = QtGui.QCheckBox(toolTip='Color shapes depending on density')
        self.checkColorify.setIcon(QtGui.QIcon(':/icons/Std_RandomColor.svg'))
        self.checkColorify.setIconSize(g_icon_size)
        self.checkColorify.stateChanged.connect(self.on_stateChanged_Colorify)

        self.comboColormap = QtGui.QComboBox(toolTip='Colormap to color shapes depending on density')
        self.comboColormap.addItem('Traffic')    # built-in default
        # See https://matplotlib.org/3.3.3/tutorials/colors/colormaps.html
        self.comboColormap.addItems(['RdYlGn_r','autumn_r','cividis','coolwarm',
                                     'Greys', 'Greys_r','summer','Wistia'])
        self.comboColormap.addItem(COLORMAP_USER)
        self.comboColormap.currentTextChanged.connect(self.on_comboColormap_changed)

        viewLayout = QtGui.QHBoxLayout()
        viewLayout.addWidget(showCoM)
        viewLayout.addWidget(self.changeRadius)
        viewLayout.addSpacing(GUI_FONT_SIZE)
        viewLayout.addWidget(self.checkColorify)
        viewLayout.addWidget(self.comboColormap)
        viewGroupBox = QtGui.QGroupBox('View')
        viewGroupBox.setLayout(viewLayout)
        layout.addWidget(viewGroupBox)

        # cdgGroupBox
        label_CdG = QtGui.QLabel('Center of mass')

        self.resultCdG = []
        for axis in range(3):
            self.resultCdG.append(QtGui.QLineEdit(self))
            self.resultCdG[axis].setObjectName('center of mass')
            self.resultCdG[axis].setReadOnly(True)

        toClipboard = QtGui.QPushButton(QtGui.QIcon(':/icons/edit-copy.svg'),'')
        toClipboard.setToolTip(f'Copy to clipboard ({Units.listSchemas(Units.getSchema())})')
        toClipboard.setFlat(True)
        toClipboard.clicked.connect(self.on_pushButton_copyToClipboardCdG)

        cdgLayout = QtGui.QHBoxLayout()
        cdgLayout.addWidget(label_CdG)
        for axis in range(3):
            cdgLayout.addWidget(self.resultCdG[axis])
        cdgLayout.addWidget(toClipboard)
        cdgGroupBox = QtGui.QGroupBox()
        cdgGroupBox.setLayout(cdgLayout)
        layout.addWidget(cdgGroupBox)

        # totalGroupBox
        label_Mass = QtGui.QLabel('Mass')

        self.resultMass = QtGui.QLineEdit(self)
        self.resultMass.setObjectName('total weight')
        self.resultMass.setReadOnly(True)

        label_Density = QtGui.QLabel('Density')

        self.resultDensity = QtGui.QLineEdit(self)
        self.resultDensity.setReadOnly(True)

        toClipboard = QtGui.QPushButton(QtGui.QIcon(':/icons/edit-copy.svg'),'')
        toClipboard.setToolTip(f'Copy to clipboard ({Units.listSchemas(Units.getSchema())})')
        toClipboard.setFlat(True)
        toClipboard.clicked.connect(self.on_pushButton_copyToClipboardTotal)

        totalLayout = QtGui.QHBoxLayout()
        totalLayout.addWidget(label_Mass)
        totalLayout.addWidget(self.resultMass)
        totalLayout.addSpacing(GUI_FONT_SIZE)
        totalLayout.addWidget(label_Density)
        totalLayout.addWidget(self.resultDensity)
        totalLayout.addWidget(toClipboard)
        totalGroupBox = QtGui.QGroupBox('Total')
        totalGroupBox.setLayout(totalLayout)
        layout.addWidget(totalGroupBox)

        qApp = QtGui.QApplication.instance()
        qApp.focusChanged.connect(self.on_focusChanged)    # emits on each focus change in FreeCAD

    def init_solids(self, objs):
        """Construct new items and compute"""
        self.find_all_centerOfMass(objs)
        for sol in range(self.solid_count):
            self.solids[sol] = SolidsWidget(
                parent=self,
                solid=g_sel[sol],
                sol=sol
            )
        self.mainGroupBox.setTitle('Selected solids: ' + str(self.solid_count))
        self.compute_centerOfMass(True)

    def on_searchL_edited(self, text):
        """Highlight all findings and scroll to first or reset"""
        if self.found_labels:
            # Reset label highlighting
            for label in self.found_labels:
                label.setFrameStyle(QtGui.QFrame.NoFrame)
            self.found_labels = []
        if text == '':
            self.scroll.ensureWidgetVisible(self.solids[0].label)
            return
        for selfsol in self.solids:
            if text.lower() in selfsol.label.text().lower():
                self.found_labels.append(selfsol.label)
                selfsol.label.setFrameShape(QtGui.QFrame.StyledPanel)
        if self.found_labels:
            self.scroll.ensureWidgetVisible(self.found_labels[0])

    def on_focusChanged(self, oldWidget, nowWidget):
        """Toggle appearance of solid being edited"""
        if 'QListView' in (type(oldWidget).__name__, type(nowWidget).__name__):
            # skip if focus changes between combo box and its dropdown list
            return
        for w in (oldWidget, nowWidget):
            # deleted (None) objects can be passed
            if w is not None and w.parent() == self.solidLayout.parentWidget():
                idx = self.solidLayout.indexOf(w)
                if idx > 0:
                    row = self.solidLayout.getItemPosition(idx)[0] - 1    # minus header
                    if gui.Selection.isSelected(g_sel[row]):
                        gui.Selection.removeSelection(g_sel[row])
                    else:
                        gui.Selection.addSelection(g_sel[row])

    def valid_selection(self, _sel):
        """Get valid objects (Shape, Mesh) from selection"""
        def sort_selection_by_label(s_):
            return s_.Label

        def sort_selection_by_tree(s_):
            return self.tree_list.index(s_.Label)

        global g_sel_user
        g_sel_user = copy.copy(_sel)

        # Add contents of group objects 'groupObjs' (breadth-first search).
        # For PartDesign objects a Group contains recursive features treated separately.
        groupObjs = ('App::DocumentObjectGroup', 'App::GeometryPython')
        i_ = 0
        while i_ < len(_sel):
            if hasattr(_sel[i_], 'Group') and _sel[i_].TypeId in groupObjs:
                _sel.extend(_sel[i_].Group)
                del _sel[i_]
            elif hasattr(_sel[i_], 'Group') and _sel[i_].TypeId == 'App::Part':
                for gp in _sel[i_].Group:
                    # Nested App:Part are considered at this place:
                    if gp.TypeId == 'App::Part':
                        _sel.append(gp)
                    # App:Part can be shapeless container of meshes (e.g. .stl's):
                    elif gp.TypeId == 'Mesh::Feature':
                        _sel.append(gp)
                i_ += 1    # where to look at the next run
            else:
                i_ += 1

        # create valid selection list
        vsel = []
        for s_ in _sel:
            if hasattr(s_, 'Shape') and s_.Shape.Volume:
                if s_.TypeId == 'App::Part':
                    # because contains bodies
                    for cs in s_.Shape.childShapes(False,False):
                        # childShapes(False,False): ignore placement of parent
                        # get first match from OutList (contains different object types)
                        for ot in s_.OutList:
                            if hasattr(ot, 'Shape') and ot.Shape.isEqual(cs):
                                # nested App:Part and App:Link in App:Part not match here
                                vsel.append(ot)
                                break
                    # App:Link in App:Part
                    for ot in s_.OutList:
                        if ot.TypeId == 'App::Link':
                            vsel.append(ot)
                elif s_.TypeId == 'Part::FeaturePython' and hasattr(s_, 'IfcType'):
                    # e.g. Arch Wall/Structure, because can contain childs
                    vsel.append(s_)
                    for it in s_.InList[1:]:
                        # include childs (Windows etc.), parent is first InList
                        if it.TypeId == 'Part::FeaturePython' and it.Shape.Volume:
                            vsel.append(it)
                else:
                    vsel.append(s_)
            elif hasattr(s_, 'Mesh') and s_.Mesh.Volume:
                vsel.append(s_)

        # remove double items (when groupObjs and containing item selected)
        vsel = list(dict.fromkeys(vsel))

        # sort valid selection list or match up with tree view
        if SORT_SELECTION:
            vsel.sort(key=sort_selection_by_label)
        else:
            tree = g_main_window.findChild(QtGui.QTreeWidget)
            iterator = QtGui.QTreeWidgetItemIterator(tree, QtGui.QTreeWidgetItemIterator.Editable)
            self.tree_list = [i_.value().text(0) for i_ in list(iterator)]
            if self.tree_list:
                vsel.sort(key=sort_selection_by_tree)
            else:
                # e.g. some FreeCAD 0.21 weekly builds
                app.Console.PrintWarning('Could not take over the sorting of the tree view\n')
                vsel.sort(key=sort_selection_by_label)

        # create valid objects list (e.g. shapes)
        import Part
        objs = []
        for i_, s_ in enumerate(vsel):
            if hasattr(s_, 'Shape'):
                # local placement correction (transform to global coordinate system)
                o_ = Part.getShape(s_)    # copy shape for transformation
                if callable(getattr(s_, 'getGlobalPlacement', None)):
                    o_.Placement = s_.getGlobalPlacement()
                else:
                    # e.g. App::Link has no method getGlobalPlacement
                    if s_.InList:
                        for it in s_.InListRecursive:
                            o_.Placement = o_.Placement.multiply(it.Placement)
                objs.append(o_)
            elif hasattr(s_, 'Mesh'):
                objs.append(s_.Mesh)
            # change vsel entry for special object cases
            if s_.TypeId == 'Part::FeaturePython' and hasattr(s_, 'ArrayType'):
                # e.g. Draft Arrays, because no ShapeColor etc. in ViewObject
                vsel[i_] = s_.OutList[0]

        if len(_sel) > len(vsel):
            app.Console.PrintWarning('Ignored invalid object from selection\n')
        if objs:
            global g_sel
            g_sel = vsel
            self.solid_count = len(objs)
        return objs

    def on_pushButton_newSelection(self):
        initial = self.checkColorify.checkState()
        self.checkColorify.setCheckState(QtCore.Qt.Unchecked)    # preserve SolidsWidget.orgColor
        self.doc = app.activeDocument()
        self.update_selection(gui.Selection.getSelection())
        self.checkColorify.setCheckState(initial)

    def on_pushButton_update(self):
        initial = self.checkColorify.checkState()
        self.checkColorify.setCheckState(QtCore.Qt.Unchecked)    # preserve SolidsWidget.orgColor
        self.update_selection(g_sel_user)
        self.checkColorify.setCheckState(initial)

    def update_selection(self, _sel):
        try:
            self.set_objects_transparent(False)
        except:
            pass
        objs = self.valid_selection(_sel)
        if not objs:
            return
        # safe way to remove all items from layout
        while self.solidLayout.count():
            child = self.solidLayout.takeAt(0)
            if child.widget():
                child.widget().deleteLater()
        self.init_solids(objs)
        if self.changeRadius.isEnabled():
            self.draw_centerOfMass()

    def load_materials(self):
        """Load density from material cards, get resource paths from FEM preferences"""
        resources = []
        if USE_BUILT_IN_MATERIALS:
            # FreeCAD.getResourceDir() returns inconsistent path separators
            # (https://forum.freecad.org/viewtopic.php?t=32036)
            resources.append(os.path.join(
                os.path.normpath(app.getResourceDir()), "Mod", "Material", "StandardMaterial")
            )
        if USE_MAT_FROM_CONFIG_DIR:
            resources.append(os.path.join(app.ConfigGet("UserAppData"), "Material"))
        if USE_MAT_FROM_CUSTOM_DIR and CUSTOM_MAT_DIR:
            resources.append(CUSTOM_MAT_DIR)
        print('Looking for material cards according to',
              'User parameter:BaseApp/Preferences/Mod/Material/Resources')
        for path in resources:
            print('  ' + path)    # cards found later with same name will override previous ones

        # Read material cards
        import importFCMat
        mat_cards = {}              # all material cards
        self.material_cards = {}    # with valid density
        for p in resources:
            if os.path.exists(p):
                for f in sorted(os.listdir(p)):
                    b, e = os.path.splitext(f)
                    if e.upper() == ".FCMAT":
                        mat_cards[b] = os.path.join(p, f)
        for mat_name in sorted(mat_cards):
            try:
                d = importFCMat.read(mat_cards[mat_name]).get('Density')
                if (len(d) > 1 and Units.Quantity(d).Value > 0):
                    self.material_base[mat_name] = Units.Quantity(d)
                    self.material_cards[mat_name] = mat_cards[mat_name]
            except:
                pass
        qtys = self.material_base.values()
        self.material_base_range = (min(qtys), max(qtys))

    def on_pushButton_editMaterial(self):
        import MaterialEditor
        material_base_old = copy.copy(self.material_base)
        MaterialEditor.openEditor()
        self.load_materials()
        # add combo items for newly created materials
        new_materials = [m for m in self.material_base if m not in material_base_old]
        for selfsol in self.solids:
            selfsol.combo.addItems(new_materials)

    def store_prefered_units(self, txt):
        """Get units set as preference"""
        self.unitForD_text = txt
        self.unitForD = self.unitForD_text.replace('³', '^3')         # density
        self.unitForL = self.unitForD_text.split('/')[1].rstrip('³')  # length
        self.unitForM = self.unitForD_text.split('/')[0]              # mass
        self.unitForV = self.unitForD.split('/')[1]                   # volume

    def convert_length(self, length):
        """Convert length from internal FreeCAD to preference unit"""
        pq = Units.parseQuantity
        return float(pq(f'{length} mm') / pq(self.unitForL))

    def convert_volume(self, volume):
        """Convert volume from internal FreeCAD to preference unit"""
        pq = Units.parseQuantity
        return float(pq(f'{volume} mm^3') / pq(self.unitForV))

    def on_comboUnitDensity_changed(self, newText):
        unitForD_prev = self.unitForD
        self.store_prefered_units(newText)
        for selfsol in self.solids:
            qty = str(selfsol.spinDens.value()) + ' ' + unitForD_prev
            selfsol.init_spinDensity(selfsol.spinDens, Units.Quantity(qty), self.unitForD)
            selfsol.spinDens.setToolTip(
                'density of ' + selfsol.label.text() + ' (in ' + self.unitForD_text + ')')
        qty = self.material_base.get('default', 0)
        SolidsWidget.init_spinDensity(self, self.defaultDensitySpin,
                                      Units.Quantity(qty),
                                      self.unitForD)
        self.defaultDensitySpin.setToolTip('set default density (in ' + self.unitForD_text + ')')
        self.compute_centerOfMass()

    def on_spinDefaultDensity_changed(self, newValue):
        qty = str(newValue) + ' ' + self.unitForD
        self.material_base['default'] = Units.Quantity(qty)
        self.compute_centerOfMass(False)
        for selfsol in self.solids:
            if selfsol.combo.currentText() == 'default':
                selfsol.on_comboMaterial_changed(1)    # trigger update of "default"
        self.compute_centerOfMass(True)

    def on_pushButton_allToDefaultDensity(self):
        self.compute_centerOfMass(False)
        for selfsol in self.solids:
            selfsol.combo.setCurrentText('default')
        self.compute_centerOfMass(True)

    def find_all_centerOfMass(self, objs):
        """Find all center of mass (CoMs) depending on the type of object."""
        import DraftVecUtils
        self.solids = [0] * self.solid_count
        self.volumes = [0] * self.solid_count
        self.masses = [0] * self.solid_count
        self.CoMs = [app.Vector(0, 0, 0)] * self.solid_count

        # function is slower than valid_selection and compute_centerOfMass
        # because of .Volume and .CenterOfMass
        progress_bar = app.Base.ProgressIndicator()
        progress_bar.start('Finding center of mass ...', self.solid_count)
        for sol in range(self.solid_count):
            self.volumes[sol] = objs[sol].Volume
            if hasattr(objs[sol], 'CenterOfGravity'):
                # FreeCAD >= 0.20
                self.CoMs[sol] = objs[sol].CenterOfGravity
            elif hasattr(objs[sol], 'CenterOfMass'):
                # FreeCAD 0.19
                self.CoMs[sol] = objs[sol].CenterOfMass
            elif hasattr(objs[sol], 'Solids'):
                for array_sol in objs[sol].Solids:
                    if hasattr(array_sol, 'CenterOfGravity'):
                        self.CoMs[sol] += array_sol.CenterOfGravity
                    else:
                        self.CoMs[sol] += array_sol.CenterOfMass
                self.CoMs[sol] /= objs[sol].Solids.__len__()
            # estimate CoM of a mesh
            elif hasattr(objs[sol], 'Points'):
                _CoM = [0,0,0]
                for f in objs[sol].Facets:
                    currentVolume = (f.Points[0][0]*f.Points[1][1]*f.Points[2][2]
                        - f.Points[0][0]*f.Points[2][1]*f.Points[1][2]
                        - f.Points[1][0]*f.Points[0][1]*f.Points[2][2]
                        + f.Points[1][0]*f.Points[2][1]*f.Points[0][2]
                        + f.Points[2][0]*f.Points[0][1]*f.Points[1][2]
                        - f.Points[2][0]*f.Points[1][1]*f.Points[0][2]) / 6.
                    for ax in range(3):
                        _CoM[ax] += ((f.Points[0][ax] + f.Points[1][ax] + f.Points[2][ax]) / 4.
                                ) * currentVolume
                for ax in range(3):
                   _CoM[ax] /= self.volumes[sol]
                self.CoMs[sol] = app.Vector(*_CoM)
            # Calculate BoundBox of all objects
            if sol == 0:
                self.boundBox = objs[sol].BoundBox
            else:
                self.boundBox = self.boundBox.united(objs[sol].BoundBox)
            progress_bar.next()
        progress_bar.stop()

    def compute_centerOfMass(self, enabled=None):
        """Compute joint center of mass from all objects if enabled. Block by setting to False."""
        if enabled is not None:
            self.compute_enabled = enabled
        if not self.compute_enabled:
            return
        self.massTot = 0.
        self.volTot = 0.
        self.TotalCoM = app.Vector(0,0,0)
        for sol in range(self.solid_count):
            volumeInUnit = self.convert_volume(self.volumes[sol])
            self.masses[sol] = volumeInUnit * self.solids[sol].spinDens.value()
            if self.masses[sol] == 0:
                continue
            self.massTot += self.masses[sol]
            self.volTot += volumeInUnit
        if self.massTot == 0:
            error_message('All masses were set to zero. Last one reset to default.')
            self.solids[-1].combo.setCurrentText('default')
            return
        for sol in range(self.solid_count):
            self.TotalCoM += 1 / self.massTot * self.masses[sol] * self.CoMs[sol]
        # output
        for sol, selfsol in enumerate(self.solids):
            selfsol.spinMass.setText(f'{self.masses[sol]:.3e}')
            selfsol.spinMass.setToolTip(f'mass of {selfsol.label.text()} (in {self.unitForM})')
        for axis in range(3):
            self.resultCdG[axis].setText(f'{self.convert_length(self.TotalCoM[axis]):.6}')
        self.resultCdG[0].setToolTip(f'center of mass X (in {self.unitForL})')
        self.resultCdG[1].setToolTip(f'center of mass Y (in {self.unitForL})')
        self.resultCdG[2].setToolTip(f'center of mass Z (in {self.unitForL})')
        self.resultMass.setText(f'{self.massTot:.6}')
        self.resultMass.setToolTip(f'total weight (in {self.unitForM})')
        self.resultDensity.setText(f'{self.massTot/self.volTot :.6}')
        self.resultDensity.setToolTip(f'total density (in {self.unitForD_text})')
        if self.doc.getObject('CenterOfMass'):
            self.draw_centerOfMass()
        if self.checkColorify.isChecked():
            self.colorify()

    def draw_centerOfMass(self):
        boundBoxL = (self.boundBox.XLength, self.boundBox.YLength, self.boundBox.ZLength)
        self.doc = app.activeDocument()    # it is possible to draw in a different document
        try:
            CoMObjs = self.doc.getObject('CenterOfMass')
            CoMObjs.removeObjectsFromDocument()    # remove childs
        except:
            CoMObjs = self.doc.addObject('App::DocumentObjectGroup', 'CenterOfMass')

        # Bounding box of valid selection
        BBoxSolid = self.doc.addObject('Part::Box','CoMBBoxOfSel')
        BBoxSolid.Placement.Base = self.boundBox.Center.sub(app.Vector(boundBoxL).multiply(0.5))
        BBoxSolid.Length = boundBoxL[0]
        BBoxSolid.Width  = boundBoxL[1]
        BBoxSolid.Height = boundBoxL[2]
        BBoxSolid.ViewObject.BoundingBox = True
        BBoxSolid.ViewObject.Transparency = 90
        BBoxSolid.ViewObject.ShapeColor = (0.5, 0.5, 0.5)
        BBoxSolid.ViewObject.Visibility = False
        CoMObjs.addObject(BBoxSolid)

        # Local coordinate system at center of masses
        lcs = self.doc.addObject('PartDesign::CoordinateSystem', 'CoMLCS')
        lcs.Placement = app.Placement(self.TotalCoM, app.Rotation(0,0,0))
        CoMObjs.addObject(lcs)

        # Sphere to represent the center of masses
        sphere = self.doc.addObject('Part::Sphere', 'CoMTotal')
        sphere.Placement.Base = self.TotalCoM
        sphere.ViewObject.ShapeColor = (0.6, 0.0, 0.0)
        sphere.ViewObject.LineWidth = 1.0
        CoMObjs.addObject(sphere)

        # Spheres for all center of mass
        if self.solid_count > 1:
            for sol in range(self.solid_count):
                if self.masses[sol] == 0:
                    continue
                sphere = self.doc.addObject('Part::Sphere', 'CoM_' + g_sel[sol].Name)
                sphere.Label = 'CoM_' + g_sel[sol].Label
                sphere.Placement.Base = self.CoMs[sol]
                if COLOR_SPHERES:
                    sphere.ViewObject.ShapeColor = self.solids[sol].orgColorFC
                else:
                    sphere.ViewObject.ShapeColor = (1.0, 1.0, 1.0)
                sphere.ViewObject.LineWidth = 1.0
                CoMObjs.addObject(sphere)

        # Planes with center of mass and size of boundBox
        cplane_name = ('CoMPlaneYZ', 'CoMPlaneXZ', 'CoMPlaneXY')
        cplane_norm = (app.Vector(1.,0.,0.),
                       app.Vector(0.,1.,0.),
                       app.Vector(0.,0.,1.))
        cplane_rot  = (app.Rotation(0,-90,  0),
                       app.Rotation(0,  0, 90),
                       app.Rotation(0,  0,  0))
        cplane_lewi =((boundBoxL[2],boundBoxL[1]),
                      (boundBoxL[0],boundBoxL[2]),
                      (boundBoxL[0],boundBoxL[1]))
        for axis in range(3):
            plane = self.doc.addObject('Part::Plane', cplane_name[axis])
            plane.Length = cplane_lewi[axis][0]
            plane.Width  = cplane_lewi[axis][1]
            plane.Placement = app.Placement(self.TotalCoM, cplane_rot[axis])
            for axi2 in range(3):
                 plane.Placement.move(cplane_norm[axi2] * boundBoxL[axi2] /-2.)
            plane.Placement.move(     cplane_norm[axis] * boundBoxL[axis] /2.)
            color = list(cplane_norm[axis])
            plane.ViewObject.LineColor = (*color, 0.)    # rgba
            plane.ViewObject.LineWidth = 1.0
            plane.ViewObject.Transparency = 100
            CoMObjs.addObject(plane)

        self.draw_update_sphere_radius()
        self.set_objects_transparent(True)
        self.doc.recompute()
        self.changeRadius.setEnabled(True)

    def draw_update_sphere_radius(self):
        boundBoxL = (self.boundBox.XLength, self.boundBox.YLength, self.boundBox.ZLength)
        radiusCOM = (1+self.changeRadius.value())/100.

        # Sphere to represent the center of masses
        sphere = self.doc.getObject('CoMTotal')
        if hasattr(sphere, 'Radius'):
            sphere.Radius = radiusCOM * max(boundBoxL)

        # Spheres for all center of mass
        # Radius of the sphere is linked to the mass of the solid: R = (m_sol/m_tot)^1/3
        for sol in range(self.solid_count):
            sphere = self.doc.getObject('CoM_' + g_sel[sol].Name)
            if hasattr(sphere, 'Radius'):
                sphere.Radius = radiusCOM * max(boundBoxL) * math.pow(self.masses[sol]/self.massTot, 1./3.)
        self.doc.recompute()

    def set_objects_transparent(self, transparent):
        if transparent:
            for sol in range(self.solid_count):
                g_sel[sol].ViewObject.Transparency = 25
        else:
            for sol in range(self.solid_count):
                g_sel[sol].ViewObject.Transparency = self.solids[sol].orgTransparency

    def on_stateChanged_showCoM(self, state):
        if state == QtCore.Qt.Checked:
            self.draw_centerOfMass()
        else:
            self.changeRadius.setEnabled(False)
            try:
                self.set_objects_transparent(False)
                self.doc.getObject('CenterOfMass').removeObjectsFromDocument()
                self.doc.removeObject('CenterOfMass')
            except:
                pass

    def on_slideButton_changeRadius(self):
        self.draw_update_sphere_radius()

    def on_stateChanged_Colorify(self, state):
        if state == QtCore.Qt.Checked:
            self.colorify()
        else:
            for sol, selfsol in enumerate(self.solids):
                name = g_sel[sol].Name
                self.doc.getObject(name).ViewObject.ShapeColor = selfsol.orgColorFC
                if COLOR_SPHERES and self.changeRadius.isEnabled():
                    self.doc.getObject('CoM_' + name).ViewObject.ShapeColor = selfsol.orgColorFC
                selfsol.spinDens.setPalette(QtGui.QPalette())    # reset to normal

    def on_comboColormap_changed(self, newText):
        if self.checkColorify.isChecked():
            self.colorify()

    def colorify(self):
        cmName = self.comboColormap.currentText()
        if cmName != 'Traffic':
            import matplotlib.cm
            cm = matplotlib.cm.get_cmap(cmName)
        densities = [selfsol.spinDens.value() for selfsol in self.solids]
        maxD = max(densities)
        minD = min([i_ for i_ in densities if i_ != 0])    # min without zeros
        for sol, selfsol in enumerate(self.solids):
            name = g_sel[sol].Name
            if self.masses[sol] == 0:
                self.doc.getObject(name).ViewObject.ShapeColor = selfsol.orgColorFC
                selfsol.spinDens.setPalette(QtGui.QPalette())    # reset to normal
                continue
            if maxD == minD:
                drel = 0    # default to low color
            else:
                drel = (densities[sol]-minD) / (maxD-minD)
            if cmName == 'Traffic':
                # density value to hsv color range green to red
                drel = math.acos(1-2*drel) / math.pi    # stretch yellow (sigmoid-like)
                h = 120 * (1-drel) / 360
                color = QtGui.QColor.fromHsvF(h, 1, 1)
            else:
                color = QtGui.QColor.fromRgbF(*cm(drel))
            color.setHsvF(color.hueF(), COLOR_SATURAT/100, color.valueF())    # skips if s > 1
            self.doc.getObject(name).ViewObject.ShapeColor = color.getRgbF()
            if COLOR_SPHERES and self.changeRadius.isEnabled():
                self.doc.getObject('CoM_' + name).ViewObject.ShapeColor = color.getRgbF()
            pal = QtGui.QPalette()
            pal.setColor(QtGui.QPalette.Base, color)
            lum = color.getRgbF()
            # https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum#dfn-relative-luminance
            for c in lum:
                c = c/12.92 if c <= 0.04045 else math.pow((c+0.055)/1.055, 2.4)
            if 0.2126*lum[0] + 0.7152*lum[1] + 0.0722*lum[2] < 0.5:
                pal.setColor(QtGui.QPalette.Text, QtCore.Qt.white)
            selfsol.spinDens.setPalette(pal)

    def on_pushButton_toPreferences(self):
        gui.runCommand('Std_DlgParameter',0)

    def on_pushButton_copyToClipboardCdG(self):
        """Copy Vector to clipboard (in FreeCAD Standard unit system)"""
        string = VALUE_DELIMITER.join(str(self.TotalCoM[axis]) for axis in range(3))
        QtGui.QGuiApplication.clipboard().setText(string)

    def on_pushButton_copyToClipboardTotal(self):
        """Copy total mass and density to clipboard (in FreeCAD Standard unit system)"""
        string = VALUE_DELIMITER.join((str(self.massTot), str(self.massTot/self.volTot)))
        QtGui.QGuiApplication.clipboard().setText(string)

    def on_pushButton_Save(self):
        for sol in range(self.solid_count):
            mat_selected = self.solids[sol].combo.currentText()
            if mat_selected == 'default':
                # remove properties set by this macro
                g_sel[sol].removeProperty('Mat_Name')
                g_sel[sol].removeProperty('Mat_Density')
            else:
                # Material Name
                tip = ('Custom', 'set by CenterOfMass Macro')
                if not hasattr(g_sel[sol], 'Mat_Name'):
                    g_sel[sol].addProperty('App::PropertyString', 'Mat_Name', *tip)
                g_sel[sol].Mat_Name = mat_selected
                # Density
                if not hasattr(g_sel[sol], 'Mat_Density'):
                    if int(app.Version()[1]) <= 21:
                        g_sel[sol].addProperty('App::PropertyString', 'Mat_Density', *tip)
                    else:
                        # 'App::PropertyDensity' available since FreeCAD 0.21
                        # -> cannot be opened in FreeCAD < 0.21 so start using with the next version
                        g_sel[sol].addProperty('App::PropertyDensity', 'Mat_Density', *tip)
                g_sel[sol].Mat_Density = str(self.solids[sol].spinDens.value()) + ' ' + self.unitForD

    def on_pushButton_Export(self):
        """Export values in a delimiter-separated table (default: Tab)"""
        encoding, delimiter = 'utf-8', VALUE_DELIMITER
        dstr = 'Tab' if VALUE_DELIMITER == '\t' else 'User-delimiter'
        fileFilter = (f'{dstr}-separated CSV (*.csv)',f'{dstr}-separated Text (*.txt)')
        fileName, selectedFilter = QtGui.QFileDialog.getSaveFileName(self,
            'Export values', os.path.expanduser('~'), ';;'.join(fileFilter))
        if fileName == '':
            app.Console.PrintWarning('No file saved \n')
            return
        app.Console.PrintMessage('Saving ' + fileName + '\n')
        densTot = self.massTot/self.volTot if self.volTot != 0 else 0.
        head = ['Number', 'Label', 'Material',
                f'Volume ({self.unitForV})',
                f'Density ({self.unitForD})',
                f'Mass ({self.unitForM})',
              *(f'Center of mass {axis} ({self.unitForL})' for axis in ['X','Y','Z'])]
        foot = ['Total', '', '',
                f'{self.volTot:.6e}',
                f'{densTot:.6e}',
                f'{self.massTot:.6e}',
              *(f'{self.convert_length(self.TotalCoM[axis]):.6e}' for axis in range(3))]
        try:
            f = open(fileName, 'w', encoding=encoding)
            f.write(delimiter.join(head) + '\n')
            for selfsol in self.solids:
                sol = selfsol.sol
                row = [f'{sol + 1}', g_sel[sol].Label, selfsol.combo.currentText(),
                       f'{self.convert_volume(self.volumes[sol]):.6e}',
                       f'{selfsol.spinDens.value():.6e}',
                       f'{self.masses[sol]:.6e}',
                     *(f'{self.convert_length(self.CoMs[sol][axis]):.6e}' for axis in range(3))]
                f.write(delimiter.join(row) + '\n')
            f.write(delimiter.join(foot))
            f.close()
            app.Console.PrintMessage(fileName + ' saved \n')
        except:
            error_message('Error writing file ' + fileName)

    def on_pushButton_Import(self):
        """Load a previously exported or an external bill of materials (BOM)"""
        encoding, delimiter = 'utf-8', VALUE_DELIMITER
        dstr = 'Tab' if VALUE_DELIMITER == '\t' else 'User-delimiter'
        fileFilter = (f'{dstr}-separated CSV or Text (*.csv *.txt)',
                      'Semicolon-CSV from Excel (*.csv)')
        fileName, selectedFilter = QtGui.QFileDialog.getOpenFileName(self,
            'Open file', os.path.expanduser('~'), ';;'.join(fileFilter))
        if fileName == '':
            return
        if selectedFilter == fileFilter[-1]:
            import locale
            encoding = locale.getpreferredencoding()
            delimiter = ';'
        with open(fileName, 'r', encoding=encoding) as csvfile:
            app.Console.PrintMessage(f'Reading from {fileName}\n')
            reader = csv.DictReader(csvfile, delimiter=delimiter, skipinitialspace=True)
            col = {}
            # find first column titled label(s), densit(y/ies), mass(es), material(s) respectively
            for name in ['label', 'densit', 'mass', 'material']:
                col[name] = next((s for s in reader.fieldnames if name in s.lower()), None)
            if col['mass'] and 'cent' in col['mass'].lower():
                # the column was titled something like "Center of mass" which is not mass
                col['mass'] = None
            col_values_w_o_label = list(col.values())[1:]
            reader_list = list(reader)    # make whole file accessible
        if not col['label']:
            error_message('Unable to find a "Label" column in the file.')
            return
        if not any(col_values_w_o_label):
            error_message('Unable to find a "Density", "Mass" or "Material" column in the file.')
            return
        if sum(bool(x) for x in col_values_w_o_label) > 1:
            app.Console.PrintWarning('  Material card takes precedence over mass over density\n')

        # Extract unit between round brackets
        try:
            unitForD = col['densit'].split('(')[1].split(')')[0]
        except:
            unitForD = self.unitForD
        try:
            unitForM = col['mass'].split('(')[1].split(')')[0]
        except:
            unitForM = self.unitForM

        self.compute_centerOfMass(False)
        loaded = [False] * self.solid_count    # bool list whether solids have been updated
        reader_labels = [row[col['label']] for row in reader_list]
        for sol, selfsol in enumerate(self.solids):
            selLabel = g_sel[sol].Label
            if selLabel in reader_labels:
                row = reader_list[reader_labels.index(selLabel)]
            elif selLabel[-3:].isnumeric() and selLabel[:-3] in reader_labels:
                # admit FreeCAD's sequential numbering (001 etc.) for same objects
                row = reader_list[reader_labels.index(selLabel[:-3])]
            else:
                continue

            if col['material'] and selfsol.combo.findText(row[col['material']]) > -1:
                selfsol.combo.setCurrentText(row[col['material']])
                loaded[sol] = True
            elif col['mass'] and row[col['mass']]:
                qty = row[col['mass']] + ' ' + unitForM
                qty_qty = Units.Quantity(qty).getValueAs(self.unitForM)
                selfsol.spinMass.setText(str(qty_qty))
                selfsol.on_spinMass_edited()    # trigger update
                loaded[sol] = True
            elif col['densit'] and row[col['densit']]:
                qty = row[col['densit']] + ' ' + unitForD
                selfsol.spinDens.setValue(Units.Quantity(qty).getValueAs(self.unitForD))
                loaded[sol] = True
        self.compute_centerOfMass(True)

        msg = str(sum(loaded)) + ' solids loaded'
        if 0 < sum(loaded) <= self.solid_count/2:
            msg += ': '
            msg += str([g_sel[sol].Label for sol in range(self.solid_count) if loaded[sol]])
        if self.solid_count/2 < sum(loaded) < self.solid_count:
            msg += '.\nNot loaded: '
            msg += str([g_sel[sol].Label for sol in range(self.solid_count) if not loaded[sol]])
        app.Console.PrintMessage(msg + '\n')


def valid_density_string(string):
    valid = False
    try:
        if Units.Quantity(string).Unit.Type == 'Density':
            valid = True
    except:
        pass
    return valid

def error_message(msg):
    app.Console.PrintError(msg + '\n')
    QtGui.QMessageBox.critical(g_main_window, 'Error', msg)


if __name__ == '__main__':
    if not valid_density_string(DEFAULT_DENSITY):
        app.ParamGet(MACRO_SETTINGS).RemString('Default density')
        error_message('Default density user parameter was set wrong. Try again.')
    elif not app.activeDocument():
        error_message('Open a document first.')
    else:
        print('Loading ' + __Name__ + ' ' + __Version__ + ' ...')
        gui.updateGui()
        if DOCKED_WINDOW:
            myWidget = CenterofmassDock()
        else:
            myWidget = CenterofmassWindow()
